概述
本节是 PolicyGuard 开发中最关键的环节,解决三个核心问题:Guard 中可获取的参数约定、用户数据的 Policy 信息整合、以及 CASL Ability 构建过程中 fields/conditions/subject 的数据结构适配。这些问题的解决直接决定了策略权限守卫能否正确工作。
问题一:Guard 参数约定
Guard 中可获取的参数
在 NestJS Guard 的 canActivate 方法中,能够获取的数据非常有限:
@Injectable()
export class PolicyGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
// 可用参数:
// 1. request.user → AuthGuard 注入的用户信息
// 2. request.params → 路由参数
// 3. request.body → 请求体数据
// 4. request.query → 查询参数
// 5. this.reflector → 装饰器元信息
}
}
typescript
约定传递给 Ability 的参数
| 参数 | 来源 | 用途 |
|---|---|---|
user | request.user | 获取用户角色、策略等关联信息 |
request | ExecutionContext | 获取 body/params/query 等请求数据 |
reflector | Guard 属性 | 获取当前路由的 Permission 元信息 |
Guard 中不应该期望获取其他动态参数,因为 Guard 执行时机在业务逻辑之前。
问题二:用户数据整合
原始查询结果的不足
通过 userService.findOne() 查询出的用户数据只包含 userRole 关联,缺少 policy 信息:
// 查询结果结构
{
id: 1,
username: 'admin',
userRole: [
{
role: {
id: 1,
name: 'admin',
rolePermission: [...] // 有权限信息
// ❌ 缺少 rolePolicy 信息
}
}
]
// ❌ 缺少扁平化的 policy 数据
}
typescript
解决方案:在 Guard 中补充 Policy 信息
// 1. 查询角色关联的策略
const rolePolicy = await this.prismaClient.rolePolicy.findMany({
where: {
roleId: { in: user.userRole.map(ur => ur.roleId) },
},
include: { policy: true },
});
// 2. 将策略信息扁平化到用户对象上
user.policies = rolePolicy.map(rp => rp.policy);
user.roleIds = user.userRole.map(ur => ur.roleId);
user.permissions = user.userRole.reduce((acc, ur) => {
return [...acc, ...ur.role.rolePermission.map(rp => rp.permission)];
}, []);
typescript
数据扁平化处理
// 删除敏感信息
delete user.password;
// 将嵌套数据提升到一级属性
user.roleIds = user.userRole.map(ur => ur.roleId);
user.policies = rolePolicy;
user.permissions = user.userRole.reduce((acc, curr) => {
return [...acc, ...curr.role.rolePermission.map(rp => rp.permission)];
}, []);
typescript
扁平化前后对比:
| 属性 | 扁平化前 | 扁平化后 |
|---|---|---|
| 角色 ID | user.userRole[0].roleId | user.roleIds |
| 策略列表 | 需要额外查询 | user.policies |
| 权限列表 | user.userRole[0].role.rolePermission | user.permissions |
问题三:Ability 构建的数据适配
3.1 Conditions 数据结构问题
Prisma 中 Json 类型字段存储的数据有两种可能的格式:
// 格式一:直接的 JSON 对象
{ "authorId": 1, "isPublished": false }
// 格式二:带 data 包装的对象(Prisma Json 类型可能自动包装)
{ "data": { "authorId": 1, "isPublished": false } }
typescript
需要在构建 Ability 时兼容两种格式:
// 判断 conditions 的实际数据结构
const conditionData =
typeof policy.conditions === 'object' && policy.conditions?.data
? policy.conditions.data
: policy.conditions;
typescript
3.2 Fields 数据结构适配
fields 字段同样可能为数组或带 data 包装的对象:
// 数组格式
["title", "content"]
// 对象格式(带 data 包装)
{ "data": ["title", "content"] }
typescript
3.3 can() 方法的参数组合
CASL 的 can() 方法有三种参数形式:
// 形式一:action + subject(无字段限制)
can('read', 'Post')
// 形式二:action + subject + conditions(条件限制)
can('update', 'Post', { authorId: 1 })
// 形式三:action + subject + fields + conditions(字段+条件)
can('update', 'Post', ['title', 'content'], { authorId: 1 })
typescript
关键:fields 传
undefined会导致 can 注册失败,必须正确处理。
扩展运算符解决方案
const localArgs: any[] = [];
// 只在有值时才加入参数
if (policy.fields) {
localArgs.push(policy.fields);
}
if (conditionData) {
localArgs.push(conditionData);
}
// 使用扩展运算符动态传参
can(policy.action, policy.subject, ...localArgs);
typescript
三种情况的参数展开:
| 场景 | fields | conditions | localArgs | 实际调用 |
|---|---|---|---|---|
| 无限制 | 无 | 无 | [] | can(action, subject) |
| 条件限制 | 无 | {authorId:1} | [{authorId:1}] | can(action, subject, {authorId:1}) |
| 完整限制 | ['title'] | {authorId:1} | [['title'],{authorId:1}] | can(action, subject, ['title'], {authorId:1}) |
问题四:Subject 实例获取
问题分析
CASL 的 can() 方法在判断具体实例时,subject 参数应该是类实例而非字符串:
// 字符串判断(定义时使用)
can('update', 'Post')
// 实例判断(运行时判断)
const post = new Post({ authorId: 1 });
ability.can('update', post) // ← 需要 Post 实例
typescript
在 Guard 中获取实例的方式有限,需要通过额外的查询。
Subject 映射方案
// 定义 subject 名称到查询方法的映射
const subjectMap: Record<string, (user: any) => Promise<any>> = {
'Post': (user) => this.sharedService.getSubject('Post', user),
'User': (user) => this.sharedService.getSubject('User', user),
// 按需扩展
};
typescript
共享模块设计
// shared/shared.service.ts
@Injectable()
export class SharedService {
constructor(private readonly prismaClient: PrismaClient) {}
async getSubject(subject: string, user: any, ...x: any[]) {
return this.prismaClient[subject.toLowerCase()].findUnique({
where: { id: user.id },
...x.length ? x[0] : {},
});
}
}
typescript
模块导入考量
不将所有模块直接导入 PolicyGuard 所在模块,原因:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 全局导入所有模块 | 简单 | 启动时全部初始化,影响性能 |
| 共享模块动态查询 | 按需加载,性能好 | 需要额外维护映射关系 |
使用共享模块方案,避免 PolicyGuard 全局化时导致所有模块在启动时初始化。
关键知识点总结
| 知识点 | 说明 |
|---|---|
| Guard 参数约定 | 固定为 user + request + reflector,不接受其他动态参数 |
| 数据扁平化 | 将嵌套的 role/policy/permission 提升到 user 一级属性 |
| Conditions 双格式 | 兼容直接 JSON 对象和 { data: {...} } 包装格式 |
| 扩展运算符 | 动态组合 can() 方法的 fields 和 conditions 参数 |
| Subject 实例 | 运行时判断需要类实例,通过共享模块查询获取 |
| 模块隔离 | 使用共享模块避免全局导入导致的性能问题 |
↑